Tutorial

Tutorial 3 - Code Refactoring, Integrating Software Tests and Error Handling

7 steps

NOTE: Getting rewards from this quest is over as it was only applicable on the Earn app when it was part of a campaign. Therefore, submissions are not reviewed or are disabled. However, you are highly encouraged to still complete the quest to practice your understanding.

Let’s now focus on improving the existing code to fully use the features that Rust allows 👀

In this tutorial, you will learn how to refactor, propagate your errors and add tests on your code by solving code challenges.

ℹ️ Code challenges are what you should do as part of the quest requirements. They might include problems, puzzles, and bug fixing as challenges. Now that this is a tutorial and not part of the Earn app any more, please take your time to solve the code challenges.

💡 Keep in mind that code challenges are indicated with red AND bolded text and requirements are indicated as bullet points.

⚠️ Please read carefully the challenge requirements as missing one can lead to a rejected submission.

For technical help on the StackUp platform & quest-related questions, join our Discord, head to the 🆘 | quest-help-forum channel and look for the correct thread to ask your question.

Helpful prior knowledge

Finish the Tutorial 2 of Getting Your Hands Dirty with Rust.


Learning Outcomes

By the end of this quest, you will be able to:

  • Understand the importance of software testing
  • Apply better error handling through match patterns and error propagation
  • Find ways to write better idiomatic Rust code

Tutorial Steps

Total steps: 7

  • Step 1: Reintroducing Rust Error-Handling

    Error-handling in Rust encourages the use of the two most notable enums, Option and Result. Unlike Python, and other languages that “catch” errors using a try-catch pattern, Rust’s error-handling encourages that errors are propagated and are known for the lifetime of the program. This is possible in Rust because Option and Result are enums.

    Enums are simple yet powerful

    Although subjective, to communicate intent between code and programmer, enums are used to represent states in a program. A state can be anything to indicate some status e.g. if a program is running or has stopped.
    Rust Option and Result are used as states to indicate

    • The existence or absence of a value which are handled by the Option enum
    • The success and failure of a process which are handled by the Result enum

    The Option enum in the Rust standard library is written as

    pub enum Option<T> {
        None,
        Some(T),
    }
    Rust

    where T is the type of the value if that value exists. Similarly, the resemblance between Option and Result enum is obvious

    pub enum Result<T, E> {
        Ok(T),
        Err(E),
    }
    Rust

    and we can imagine that the Ok variant of Result is similar to the Some variant of Option. The only difference is that Result has an Err variant that must have a type E, the value and type of the error.

    Handling Errors

    Rust allows us to not handle errors through unwrap functions and through the panic! macro.

    fn main() {
      let number = "23";  // a string of type &str
      let parsed_number = number.parse::<usize>().unwrap();  // attempting to parse number with unwrap
      panic!()  // end program prematurely with panic macro
      println!("{}", parsed_number);  // code unreachable because of panic
    }
    Rust

    However, it is bad practice to not handle errors especially as we might need to process the error value or even record errors to our logs. To fix the code, one might remove the panic macro and add a return type of Result for the main function then lastly, add a match pattern to handle the values for success and error.

    use std::num::ParseIntError;  // add the error type into the namespace
    
    fn main() -> Result<(), ParseIntError> {
    	let number = "23"; // a string of type &str
    	let parsed_number = number.parse::<usize>();
    	match parsed_number {
        		Ok(val) => println!("{}", val),
        		Err(err) => return Err(err),
    	}
    	Ok(())
    }
    Rust

    Since this is a common pattern, Rust allows us to use the question mark operator. This can finally be shortened as shown below.

    use std::num::ParseIntError;
    
    fn main() -> Result<(), ParseIntError> {
    	let number = "23"; // a string of type &str
    	let parsed_number = number.parse::<usize>()?;  // Replaced with the question mark instead of a match
    	println!("{}", parsed_number);
    	Ok(())
    }
    Rust

    ℹ️ This step is useful for the following steps as there will be challenges that need at least some knowledge of error-handling in Rust.

  • Step 2: Refactoring Account Creation

    So far, our Rust project has been working fine, if we believe it does. However, you might have noticed some inconsistencies and might have realised that “this can be refactored”.

    For example, this code snippet from our Rust project code seems redundant. And the annotations represent our frustrations 🤣

    For example, we already have the function to initialise the database, so what’s that doing there?

    You might have solutions to improve this code. Maybe even find bugs! So let’s try to improve it. The goal is to get from the old code to the new code as shown below

    Adding tests

    Rust has a good way to add basic tests using the #[cfg(test)] compiler attribute and the #[test] compiler attribute. They are annotations to tell the compiler that this part of the code will only run during a cargo test command. For example, there is already a provided code to test the Luhn algorithm. So your cargo test output will look similar as shown below

    As part of the quest challenge, add the following code anywhere in database.rs, preferably on the last line.

    fn fetch_account(account: &str) -> Result<Account> {
    	let db = initialise_bankdb()?;
    	let mut stmt = db.prepare("SELECT id, account_number, balance, pin FROM account")?;
    	let accounts = stmt.query_map([], |row| {
        	Ok(Account {
            	id: row.get(0)?,
            	account_number: row.get(1)?,
            	balance: row.get(2)?,
            	pin: row.get(3)?,
        	})
    	})?;
    
    	let accounts = accounts.flatten().find(|acc| acc.account_number == account);
    	if let Some(fetched_account) = accounts {
        		Ok(fetched_account)
    	} else {
        		Err(rusqlite::Error::QueryReturnedNoRows)
    	}
    }
    
    #[cfg(test)]
    mod tests {
    	use super::*;
    	
    	#[test]
    	fn created_account_is_correct_fetched_from_db() -> Result<()> {
        		let acc1 = Account::new()?;
        		let acc2 = fetch_account(&acc1.account_number)?;
    
        		assert_eq!(acc1.id, acc2.id);
    
        		Ok(())
    	}
    }
    Rust

    Then, we should configure the database_path() function so that if we aren’t running tests, it uses a different database_path(). Copy and replace the original database_path() function with the code snippet below

    #[cfg(not(test))]
    fn database_path() -> PathBuf {
    	PathBuf::from("bank.s3db")
    }
    
    #[cfg(test)]
    fn database_path() -> PathBuf {
    	PathBuf::from("mock_bank.s3db")
    }
    Rust

    ℹ️ You might have noticed the #[cfg(test)] and #[cfg(not(test))]. These are compiler attributes, specifically called Configuration Conditional Checks. Like the name implies, they change your code during compile time based on the set of conditions, for example, changing what function or statement to use if we run a cargo test or not. See more in the Rust Reference.

    This is required so that we can check your code is running correctly.

    Challenge

    In this quest’s challenge, you will have to change both database.rs and main.rs to get to the new code as shown from the previous sample screenshot. Your code should be EXACTLY similar to the sample screenshot.

    The logic of the code is up to you. However, the challenge requires you to make the following code changes that are necessary to complete this quest:

    • create_account should have a return type of Result<Account> in database module
    • Account should have an implementation and have a new function in database module
    • new function should have a return type of Result<Account> or Result<Self> in database module
    • create_account should only be called inside Account::new() in database module
    • Loop from old code is gone in main.rs
    • Tests should pass

    Always take note that the Result is from rusqlite::Result.

    💡Here’s a hint: adding AccountNumber::default in the Account::new function as the first statement.

    🧠 Tips:

    • Use the question mark operator (?) to propagate errors properly. See the Rust by Example section about the operator.
    • Use pattern matching. They will help you a lot!
    • Run cargo clippy and let it show what you can improve.
    • Implement the Default trait on Account through derive to avoid the pattern Account { id: 0, account_number … }.
    • Return Result’s variants Err and Ok as much as possible if there is no other way.
    • Replace unwraps by propagating the errors within closures!

    🛑 Do not ever run the following cargo test command

    cargo test

    This is because there is a limitation of using rusqlite, specifically SQLite itself as it only works single-threaded. To run your tests on your new code, please run the following command all throughout the rest of the campaign.

    cargo test -- --test-threads=1

    To give you more of an idea of what a test success looks like, it should be similar as shown below

    Now that you have reached here, congratulate yourself for persevering throughout the campaign! You can do it! 🎉

  • Step 3: There is a bug in our code. No... Seriously

    Have you noticed the bug? If you have, then congratulations 🥳

    If you haven’t, then that’s fine, most of the time, bugs are harder to detect but to catch them easily, we have to catch them early. Hence, we will write some testing code 🛠️

    So what’s the bug? The bug is when doing the transfers, the target account was not checked properly. Here is a screenshot of the bug below

    The bug happens because we haven’t checked if the second account is valid or if it even exists.

    Refactoring Again

    But first, we have to refactor the code, we will remove some eprintln! and println! macros and adjust our return type accordingly for the transfer.

    We also add the check if the target account exists so we can properly transfer. Here are the code changes for the transfer function in the database module.

    New database::transfer code. Copy this and replace the old function.

    pub fn transfer(
    	amount: &str,
    	pin: &str,
    	origin_account: &str,
    	target_account: &str,
    ) -> Result<(Account, Account)> {
    	if *origin_account == *target_account {
        	return Err(rusqlite::Error::QueryReturnedNoRows); // Makes sense. We haven't returned any.
    	}
    
    	// Create new binding
    	let origin_account = fetch_account(origin_account)?;
    	let target_account = fetch_account(target_account)?;
    
    	let correct_pin = origin_account.pin == pin;
    
    	if correct_pin {
    
        	let amount = amount
            	.parse::<u64>().map_err(|_| {
                	rusqlite::Error::QueryReturnedNoRows
            	})?;
    
        	if amount > origin_account.balance {
        	} else {
            	let db = initialise_bankdb()?;
            	// Add money to account 2
            	db.execute(
                	"UPDATE account SET balance = balance + ?1 WHERE account_number=?2",
                	(amount, &target_account.account_number),
            	)?;
    
            	// Subtract money from account 1
            	db.execute(
                	"UPDATE account SET balance = balance - ?1 WHERE account_number=?2",
                	(amount, &origin_account.account_number),
            	)?;
    
        	};
    	} else {
        	return Err(rusqlite::Error::QueryReturnedNoRows);
    	}
    
    	let origin_account = fetch_account(&origin_account.account_number)?;
    	let target_account = fetch_account(&target_account.account_number)?;
    
    	Ok((origin_account, target_account))
    }
    Rust

    ℹ️ For now, let’s ignore running the compiled binary and focus on the testing since we have removed printing information doing transfers when running the program.

    Adding the tests

    To verify that our code is working properly, we should start by running the command cargo test. Add this code snippet of the annotated test function transferred_balance_is_correct in the last line of the database.rs file in your tests module

    	#[test]
    	fn transferred_balance_is_correct() -> Result<()> {
    		// 1) Fill the missing code here
    		let deposit_balance = "10000";
    		
    // let's deposit first
        		deposit(deposit_balance, &origin_account.pin, &origin_account.account_number)?;
    		// 2) Fill the missing code here
    
    		assert_eq!(*deposit_balance, origin_account.balance.to_string());
    		// 3) Fill the missing code here
    
    
    assert_eq!("0".to_string(), origin_account.balance.to_string());
        		assert_eq!(deposit_balance.to_owned(), target_account.balance.to_string());
    		// Nothing further here
    		Ok(())
    	}
    	// Our test code for Step 1 below
    #[test]
    	fn created_account_is_correct_fetched_from_db() -> Result<()> {
    	// -- snipped --
    Rust

    ☝️ However, as part of a challenge, you will have to fix the code snippet which will be discussed in the next section.

    Challenge

    In this step’s challenge, the requirements for this quest challenge are

    • Do not change the order of the already filled statements inside transferred_balance_is_correct. Fill only below or above them indicated as comments “Fill the missing code here”.
    • No code changes in other parts of the module EXCEPT transferred_balance_is_correct.
    • It should return a successful test
    • ALL requirements of this quest challenge must be followed

    Read further for hints and tips 😏

    💡 The following hint to help you finish writing the tests is adding the necessary variables to transfer between two accounts by using the Account::new() function. The assert macros in this challenge should never be touched as they are already enough to write a test for this challenge.

    🧠 Tips:

    • Use the question mark operator (?) to propagate errors properly. See the Rust by Example section about the operator.
    • Always use the fetch_account function to update and redeclare your variables.

    Once done, you can check if the cargo test command returns a new and successful test

  • Step 4: Quick Recap

    Testing in software development is like checking your work to make sure everything runs smoothly. It's important to test important parts of your software, like complicated functions or how it connects with other programs. These tests help catch mistakes early and make sure everything works as it should, especially when you make changes or add new features.

    But not everything needs a test. For example, simple things like basic functions or parts that keep changing might not need testing right away. It's also okay to skip testing if you're just trying out new ideas or working on something really old that might be tricky to test. The key is finding a balance between testing enough to catch problems and not spending too much time testing things that don't really need it.

    Since our code contains a lot of newer code changes, it is up to you on how to further refactor them so that they still look like the way it runs before.

    There is a lot to improve but it’s mostly up to you. For example, we can do the same to both deposit and withdraw functions by adding tests and removing the print macros. We can even create new helper functions for adding or subtracting balances of an account, thereby reusable for withdraw, deposit and transfer functions.

    Please answer this survey. It helps a lot

    Now that you have finally finished the quest 3 of the campaign, please answer the survey. This helps a lot for the future Rust campaigns and possible revisions of existing Rust content!

    Click the link to start the survey: https://forms.gle/1Q58iJCTedY31W448

  • Step 5: Getting Your Hands Dirty with Rust! Preparing your Submission! Part 1

    You have finally reached the end of the campaign! 🎉

    Now, to make sure you successfully completed this quest, there is 1 deliverable that is required for this quest - one image file. Specifically, two screenshots are merged into one image (the the rest of the instructions second screenshot is on the next step).

    Your first screenshot should show:

    • your full screen, including your taskbar (for Windows and Linux) / dock (for MacOS)
    • your code for the challenge EXCLUDING test code for it mentioned in Step 1. ALL challenge requirements should be followed
    • your file explorer opened showing the files and folders of the project
    • make sure that all parts visible in the expected output below are also visible in your screenshots
    Expected Output for the first screenshot

    Proceed to the next step Part 2

  • Step 6: Getting Your Hands Dirty with Rust! Preparing your Submission! Part 2

    Your second screenshot should show:

    • your full screen including your taskbar (for Windows and Linux) / dock (MacOS)
    • all of your test code for both challenges from Step 1 and Step 2 ALL challenge requirements should be followed
    • your file explorer opened showing the files and folders of the project
    • your cargo test -- --test-threads=1 output. It should show the following tests
      • All Luhn tests
      • All database tests
    • make sure that all parts visible in the expected output below are also visible in your screenshots

    ℹ️ For non-VSCode or non-VSCode-fork users, you can show the file contents using your file manager and a separate terminal for the cargo run command.

    Expected Output for Second screenshot

    Proceed to next step Part 3

  • Step 7: Getting Your Hands Dirty with Rust! Preparing your Submission! Part 3

    Merge all your screenshots side-by-side. Annotate which ones are from Step 1 and which ones are from Step 2.  See the example merged screenshot below

    Expected Merge Screenshot Output

    ℹ️ If you have trouble showing all the necessary deliverables as a screenshot e.g. screen too small, you can merge more than two full screen screenshots e.g. 3 full screen screenshots (one for Account struct in Step 1, one for create_account function in Step 1, one for transferred_balance_is_correct function in mod tests in Step 2 and one for the cargo test output for Step 2). Remember to annotate them accordingly.

    You can use any editing tool for that e.g. using Powerpoint to merge them on Windows, using Preview on MacOS/OSX or using ImageMagick on Linux e.g. running the following command

    convert image23.png image18.png +append C26_Q3_yourusername.png

    Or use a web tool such as https://www.adobe.com/express/feature/image/combine.

    Refer to the images if you are unsure. If the image is too small, right click on the image and press on 'Open Image in New Tab'.

    When labelling your screenshot, make sure to follow the format provided C26_Q3_yourusername.png.

    Note: You can retrieve your StackUp username by clicking on the burger menu at the top right-hand corner of this page. You can check out this article for a reference on how to obtain it!

    By submitting the quest, please note that our StackUp Policy prohibits the use of multiple accounts by a single user and the submission of copied work.

Have you completed this tutorial? Click below to mark as complete

Help Center Need help?

Find articles to support you through your journey or chat with our support team.

Help Center